"""
   pymunx :: A pymunk API demo

  chipmunk (physics engine dll)
  |
  --- pymunk (ctypes bindings)
      |
      --- pymunx api
    |
    --- pygame


  For the latest source code, please visit:

            Home: http://wiki.laptop.org/go/Elements
   Documentation: http://wiki.laptop.org/go/Elements/Documentation
   
 Sources & Demos: http://www.linuxuser.at/elements
           Forum: http://www.slembcke.net/forums/viewforum.php?f=6
           
             IRC: #pymunk on irc.freenode.net
      
   > About <

  Pymunx was started by Chris Hager (chris@linuxuser.at) in March 2008.
  (Please send me feedback :) I just got started on this, more will come
  frequenty, so check back soon :)
     
  License: GPL, 2008


   > How-to get the Sources, Examples & Chipmunk Library <
   
     svn checkout http://pymunk.googlecode.com/svn/trunk pymunk-read-only
        

   > Version <
   
     pymunx-0.5 (commit: 10. March 2008)
        

   > Latest Changes <


   > How to use pymunx <

  pymunx requires the pymunk bindings (in pymunk/) and the chipmunk library for your platform.
  You could simply place pymunx.py in the directory with all the other demos (has pymunk/ as subdirectory :)
  Just make a svn checkout and you'll see :)

      
   > Typical example with pygame <

     world = pymunx()
     world.add_wall((100, 200), (300, 200))
     
     # Main Game Loop:
     while running:
       # Event Checking
       # ...

    screen.fill((255,255,255))

    # Update & Draw World
    world.update()
    world.draw(screen)

    # Flip Display
    pygame.display.flip()
    
    # Try to stay at 50 FPS
    clock.tick(50)       


   > Quick Info about the Physical Properties <

     Inertia:    The moment of inertia is like the mass of an object, but applied to its rotation. 
        An object with a higher moment of inertia is harder to spin.

     Elasticity: A value of 0.0 gives no bounce, while a value of 1.0 will give a 'perfect' bounce. 
        However due to inaccuracies in the simulation using 1.0 or greater is not recommended however.
  
  Friction:   (Friction coefficient): Chipmunk uses the Coulomb friction model, a value of 0.0 is frictionless.
  
  Velocity:   The surface velocity of the object. Useful for creating conveyor belts or players that move around. 
        This value is only used when calculating friction, not the collision.
"""

import pygame
from pygame.locals import *
from pygame.color import *

import pymunk as pm
import pymunk.util as util
from pymunk import Vec2d

from os import chdir
from os import system
from os.path import isfile

from math import pi
from math import fabs
from math import sqrt
from math import degrees

from sys import exit
from time import sleep
from random import shuffle
from threading import Thread
from traceback import print_exc

# infinite ~ 10^100 :)
inf = 1e100
    
# Some Hex Tools
def hex2dec(hex): return int(hex, 16)
def hex2rgb(hex): 
  if hex[0:1] == '#': hex = hex[1:]; 
  return (hex2dec(hex[:2]), hex2dec(hex[2:4]), hex2dec(hex[4:6]))

# Threaded, delayed function
class delay_function(Thread):
  def __init__(self, delay, callback):
    Thread.__init__(self)
    self.delay = delay
    self.callback = callback
  def run(self):
    sleep(self.delay)
    self.callback()
    
# Encoding should run in the Background :)
class avi_encoder(Thread):
  def __init__(self, dir, fn, callback):
    Thread.__init__(self)
    self.callback = callback
    self.dir = dir
    self.fn = fn
  def run(self):
    print self.dir, self.fn
    try:
      chdir(self.dir)
      print "Writing to %s.avi..." % self.fn
      self.fn = self.fn.replace("%s/" % self.dir, "")
      system('mencoder "mf://%s*.tga" -mf fps=30 -o %s.avi -ovc lavc -lavcopts vcodec=msmpeg4v2:vbitrate=800' % (self.fn, self.fn))
      system("rm %s*.tga" % self.fn)      
      chdir("..")
    except:
      print "Sorry, only working on linux ... and not well tested :/ Error:"
      print_exc()
    # Report that encoding (try) is over
    self.callback()

# pymunx Main Class  
class pymunx:
  element_count = 0  # Approx. Count of current Elements
  fixed_color = None  # Fixed Color in Hex or RGB
  filecounter = 0    # Current Screenshot File Counter
  capture_to = False  # False if no Screencast, filename if record
  info_surface = False  # Info Box for the center of the image (Encoding, ...)
  show_help = True  # Blit Help on Top Left of the Screen  

  def __init__(self, gravity=(0.0,-900.0)):
    """ Init function: init pymunk, get screen size, init space, ...
        Parameter: gravity == (int(x), int(y))
        Returns: pymunx()
    """
    self.run_physics = True 
    self.gravity = gravity
    
    # Python Stuff
    self.font = pygame.font.Font(None, 32)
    self.font_xxl = pygame.font.Font(None, 38)
    
    # Physics Init
    pm.init_pymunk()

    # Init Colors
    self.init_colors()
    
    # This string will be blit in the top left corner at each update
    self.set_info("")
        
    # Get Screen Size
    self.autoset_screen_size()
    
    # Space Init
    self.space = pm.Space()
    self.space.gravity = Vec2d(gravity)
    self.space._space.contents.elasticIterations = 10
    self.space.resize_static_hash()
    self.space.resize_active_hash()

  def init_colors(self):
    """ Init self.colors with a fix set of hex colors 
    """
    self.cur_color = 0
    self.colors = [
      "#737934", "#729a55", "#040404", "#1d4e29", "#ae5004", "#615c57",
      "#6795ce", "#203d61", "#8f932b"
    ]
    shuffle(self.colors)
#    for c in THECOLORS:
#      self.colors.append(THECOLORS[c])

  def set_color(self, clr):
    """ Set a color for all future Elements, until reset_color() is called 
        Parameter: clr == (Hex or RGB)
    """
    self.fixed_color = clr
  
  def reset_color(self):
    """ All Elements from now on will be drawn in random colors 
    """
    self.fixed_color = None
    
  def get_color(self):
    """ Get a color - either the fixed one or the next from self.colors 
        Returns: clr = ((R), (G), (B)) 
    """
    if self.fixed_color != None:
      return self.fixed_color
      
    if self.cur_color == len(self.colors): 
      self.cur_color = 0
      shuffle(self.colors)
  
    clr = self.colors[self.cur_color]
    if clr[:1] == "#":
      clr = hex2rgb(clr)
    
    self.cur_color += 1
    return clr
      
  def toggle_help(self):
    """ Toggle Help on and off
    """
    self.show_help = not self.show_help

  def set_info(self, txt):
    """ Create the Surface for the Infotext at the Upper Left Corner 
        Parameter: txt == str()
    """
    txt = txt.splitlines()
    self.infostr_surface = pygame.Surface((500, 800))
    self.infostr_surface.fill((255,255,255))
    self.infostr_surface.set_colorkey((255,255,255))
    
    y = 0
    for line in txt:
      if len(line.strip()) == 0:
        y += 16
      else:
        text = self.font.render(line, 1,THECOLORS["black"])
        self.infostr_surface.blit(text, (0,y))
        y += 26
  
  def messagebox_show(self, txt, delay=None):
    """ Add a message box at the center on drawing 
        Parameter: txt == str()
        Optional: delay (in seconds, until box disappears)
    """
    s = self.font_xxl.render(txt, 1, THECOLORS["black"])
    x,y,w,h = s.get_rect()
    self.info_surface = pygame.Surface((w+40,h+20))
    self.info_surface.fill((255,255,255))
    pygame.draw.rect(self.info_surface, (0,0,0), (x,y,w+40,h+10), 4)
    self.info_surface.blit(s, (20, 8))
    if delay != None:
      x = delay_function(delay, self.messagebox_hide)
      x.start()
    
  def messagebox_hide(self):
    """ Hide the message box
    """
    self.info_surface = False
    
  def screenshot(self, filename='screenshot', ext='tga'):
    """ Make a Screenshot in .tga format, if there is no screencast running
        Optional: filename == str() (no extension), ext == str() (does not work -- always saves as .tga)
    """
    if self.capture_to != False:
      return
      
    if filename[-4:-3] == ".": filename = filename[:-4]
    elif filename[-3:-2] == ".": filename = filename[:-3]

    # Create Surface to Blit Elements on
    surface = pygame.Surface((self.display_width, self.display_height))
    surface.fill((255, 255, 255))
    self.draw(surface, True)
    
    # Save
    try:
      fn = self.save_surface(surface, "snapshots/%s" % filename, ext)
      self.messagebox_show("Saved as: %s" % fn, 2)
    except: pass

  def screencast_start(self, fn='screencast'):
    """ Starts saving one image per frame in snapshots/ (as .tga), for encoding with mencoder
        Optional: fn == str() (filename without extension)
    """
    self.capture_to = "snapshots/%s" % fn
    system("rm %s*.tga" % self.capture_to)
  
  def screencast_stop(self):
    """ Stop the image saving and start encoding (mencoder) the images to a .avi video
    """
    self.run_physics = False
    self.filecounter = 0
    self.messagebox_show("Encoding Video [ %s.avi ]..." % self.capture_to)
    encoder = avi_encoder("snapshots", self.capture_to, self.screencast_encode_callback)
    encoder.start()
    self.capture_to = False
      
  def screencast_encode_callback(self):
    """ Callback function when encoding is done -> remove info & resume physics
    """
    self.messagebox_hide()
    self.run_physics = True
    
  def save_surface(self, surface, fn='surface', ext='tga'):
    """ Saves a surface to a local file
        Parameter: surface == pygame.Surface()
        Optional: fn == str(fn_without_ext), ext == str()
        Returns: fullname == str(full_name_of_file)
    """
    fullname = None
    while fullname == None or isfile(fullname):
      self.filecounter += 1
      z = "0" * (5-len(str(self.filecounter)))
      fullname = "%s_%s%i.%s" % (fn, z, self.filecounter, ext)
      
    pygame.image.save(surface, fullname)
    return fullname
  
  def clear(self):
    """ Clear & Reset the Physic Space (Remove all Elements)
    """
    pm.init_pymunk()

    # Space Init
    self.space = pm.Space()
    self.space.gravity = Vec2d(self.gravity)
    self.space.resize_static_hash()
    self.space.resize_active_hash()
    
    self.element_count = 0
    self.filecounter = 0

  def flipy(self, y):
    """ Convert pygame y-coordinate to chipmunk's 
        Parameter: y == int()
        Returns: int(y_new)
    """
    return -y+self.display_height
  
  def Vec2df(self, pos):
    """ pos -> Vec2d (with flipped y)
        Parameter: pos == (int(x), int(pygame_y))
        Returns: Vec2d(int(x), int(chipmunk_y))
    """
    return Vec2d(pos[0], self.flipy(pos[1]))
    
  def autoset_screen_size(self, size=None):
    """ Get the current PyGame Screen Size, or sets it manually
        Optional: size == (int(width), int(height)) 
    """
    if size != None:
      self.display_width, self.display_height = size
      return
      
    try:
      x,y,w,h = pygame.display.get_surface().get_rect()
      self.display_width = w
      self.display_height = h
    except:
      print "pymunx Error: Please start pygame.init() before loading pymunx"
      exit(0)

  def is_inside(self, pos, tolerance=3000):
    """ Check if pos is inside screen + tolerance
        Parameter: pos == (int(x), int(y))
        Optional: tolerance == int(pixels)
        Returns: True if inside, False if outside
    """
    x, y = pos
    if x < -tolerance or x > self.display_width+tolerance or y < -tolerance or y > self.display_height+tolerance:
      return False
    else:
      return True
      
  def update(self, fps=50.0, steps=5):
    """ Update the Physics Space 
        Optional: fps == int(fps), steps == int(space_steps_per_udate)
    """
    # Update physics
    if self.run_physics:
      dt = 1.0/fps/steps
      for _ in range(steps): 
        self.space.step(dt)

  def draw(self, surface, addtext=True):
    """ Draw All Shapes, and removes the ones outside 
        Parameter: surface == pygame.Surface()
        Optional: addtext == True/False (if True, also add Info-Text to surface)
    """
    to_remove = []

    # Draw all Shapes
    for shape in self.space.shapes:
      if not self.draw_shape(surface, shape):
        to_remove.append(shape)
    
    # Maybe only add shapes. If so, return now
    if not addtext: 
      return
    
    # Screencast?
    if self.capture_to != False: 
      self.save_surface(surface, self.capture_to)
    
    # Draw Info-Text
    if self.show_help:
      surface.blit(self.infostr_surface, (10,10))
    if self.info_surface != False: 
      surface.blit(self.info_surface, (300,300))
    
    # Remove Outside Shapes
    for shape in to_remove:
      self.space.remove(shape)
      self.element_count -= 1
      
  def draw_shape(self, surface, shape):
    """ Draw a shape (can be either Circle, Segment or Poly).
        Parameter: surface == pygame.Surface(), shape == pymunk.Shape()
        Returns: True if shape is inside screen, else False (for removal)
    """
    s = str(shape.__class__)    
    if 'pymunk.Circle' in s:
      # Get Ball Infos
      r = shape.radius
      v = shape.body.position
      rot = shape.body.rotation_vector
    
      # Draw Ball
      p = int(v.x), int(self.flipy(v.y))
      pygame.draw.circle(surface, shape.color, p, int(r), 3)
  
      # Draw Rotation Vector
      p2 = Vec2d(rot.x, -rot.y) * r * 0.9
      pygame.draw.aaline(surface, shape.color2, p, p+p2, 2)
      
      # Remove if outside
      if not self.is_inside(p): return False
      
    elif 'pymunk.Segment' in s:
      p1 = shape.body.position + shape.a.rotated(degrees(shape.body.angle))
      p2 = shape.body.position + shape.b.rotated(degrees(shape.body.angle))
      p1 = (p1[0], self.flipy(p1[1]))
      p2 = (p2[0], self.flipy(p2[1]))  
      pygame.draw.lines(surface, shape.color, False, [p1, p2], 3)

      # Remove if outside
      if not self.is_inside(p1) or not self.is_inside(p2): return False
    
    elif 'pymunk.Poly' in s:
      # Correct Poly y-Coordinates
      points = []
      for p in shape.get_points(): 
        points.append((p.x, self.flipy(p.y)))

      # Close the Polygon
      if points[-1] != points[0]:
        points.append(points[0])
    
      # Draw Poly
      pygame.draw.polygon(surface, shape.color, points, 3)

      # Remove if outside
      if not self.is_inside(points[0]): return False

      #if len(self.points) > 1:
      #pygame.draw.lines(surface, shape.color, False, self.points, 2)

    return True
    
  def add_wall(self, p1, p2, friction=1.0, elasticity=0.1, mass=inf, inertia=inf):
    """ Adds a fixed Wall pos = (int(x), int(y))
        Parameter: p1 == pos(startpoint), p2 == pos(endpoint)
        Optional: See #physical_parameters
        Returns: pymunk.Shape() (=> .Segment())
    """
    body = pm.Body(mass, inertia)
    shape= pm.Segment(body, self.Vec2df(p1), self.Vec2df(p2), 2.0)  
    shape.friction = friction
    shape.elasticity = elasticity
    
    shape.color = self.get_color()
    shape.color2 = self.get_color()

    self.space.add(shape)
    self.element_count += 1
    return shape
    
  def add_ball(self, pos, radius=15, density=0.1, inertia=1000, friction=0.5, elasticity=0.3):
    """ Adds a Ball 
        Parameter: pos == (int(x), int(y))
        Optional: See #physical_parameters
        Returns: pymunk.Shape() (=> .Circle())
    """
    mass = density * (radius * radius * pi)

    # Create Body
    body = pm.Body(mass, inertia)
    body.position = self.Vec2df(pos)
    
    # Create Shape
    shape = pm.Circle(body, radius, Vec2d(0,0))
    shape.friction = friction
    shape.elasticity = elasticity
    
    shape.color = self.get_color()
    shape.color2 = self.get_color()

    # Append to Space
    self.space.add(body, shape)
    self.element_count += 1
    return shape

  def add_square(self, pos, a=18, density=0.1, friction=0.2, elasticity=0.3):
    """ Adding a Square | Note that a is actually half a side, due to vector easyness :) 
        Parameter: pos == (int(x), int(y))
        Optional: a == (sidelen/2) | #physical_parameters
        Returns: pymunk.Shape() (=> .Poly())
    """
    mass = density * (a * a * 4)
    
    # Square Vectors (Clockwise)
    verts = [Vec2d(-a,-a), Vec2d(-a, a), Vec2d(a, a), Vec2d(a,-a)]
    
    # Square Physic Settings
    inertia = pm.moment_for_poly(mass, verts, Vec2d(0,0))
  
    # Create Body
    body = pm.Body(mass, inertia)
    body.position = self.Vec2df(pos)
  
    # Create Shape
    shape = pm.Poly(body, verts, Vec2d(0,0))
    shape.riction = friction
    shape.elasticity = elasticity

    shape.color = self.get_color()
    shape.color2 = self.get_color()
          
    # Append to Space
    self.space.add(body, shape)
    self.element_count += 1
    return shape
        
  def add_poly(self, points, density=0.1, friction=2.0, elasticity=0.3):
    """ Mass will be calculated out of mass = A * density 
        Parameter: points == [(int(x), int(y)), (int(x), int(y)), ...]
        Optional: See #physical_parameters
        Returns: pymunk.Shape() (=> .Poly())
    """
    # Make Vec2d's out of the points
    poly_points = []
    for p in points:
      poly_points.append(self.Vec2df(p))
      
    # Reduce polygon points
    poly_points = util.reduce_poly(poly_points)
    if len(poly_points) < 3: 
      return False
    
    # Make a convex hull
    poly_points = util.convex_hull(poly_points)
    
    # Make it counter-clockwise
    if not util.is_clockwise(poly_points):
      poly_points.reverse()
    
    # Change vectors to the point of view of the center
    poly_points_center = util.poly_vectors_around_center(poly_points)
    
    U, A = util.get_poly_UA(poly_points_center)
    A *= 2.5   # get_poly_UA is not working properly with the area :)
    mass = A * density
    
    # Calculate A Good Momentum
    moment = pm.moment_for_poly(mass, poly_points_center, Vec2d(0,0))

    # Create Body
    body = pm.Body(mass, moment)

    center = cx, cy = util.calc_center(poly_points)
    body.position = Vec2d((cx, cy))

    # Create Shape
    shape = pm.Poly(body, poly_points_center, Vec2d(0,0))
    shape.friction = friction
    shape.elasticity = elasticity

    shape.color = self.get_color()
    shape.color2 = self.get_color()
    
    # Append to Space
    self.space.add(body, shape)
    self.element_count += 1
    return shape

    
  def apply_impulse(self, shape, impulse_vector):
    """ Apply an Impulse to a given Shape
        Parameter: shape == pymunk.Shape(), impulse_vector == (int(x), int(y))
    """
    x, y = impulse_vector
    shape.body.apply_impulse(Vec2d(x, self.flipy(y)), Vec2d(0,0))

  def get_element_count(self):
    """ Get the current (approx.) element count
        Returns: int(self.element_count)
    """
    return self.element_count

#
# The functions below are only for experimental usage
#    
  def reduce_points(self, pointlist, tolerance=25):
    # Heavily reduce Line Points
    points_new = []
    x = 0
    y = 0
    for p in pointlist:
      px, py = p
      dx = fabs(x - px)
      dy = fabs(y - py)
      
      if dx > tolerance and dy > tolerance:
        x, y = p
        points_new.append(p)
    
    if points_new[-1] != pointlist[-1]:
      points_new.append(pointlist[-1])
      
    return points_new
    
  def add_segment(self, points, inertia=500, mass=150.0, friction=3.0):
    # Add A Multi-Line Segment 
    # Problem: They don't Collide yet

    # Reduce Points
    pointlist = self.reduce_points(points)
    print "Reduced Segment Points: %i -> %i" % (len(points), len(pointlist))

    # Get the Center of the Segment
    center = cx, cy = util.calc_center(pointlist)
    
    # Arrange vectors around center
    pointlist = util.poly_vectors_around_center(pointlist, False)

    # Get Moment of Inertia
    moment = pm.moment_for_poly(mass, pointlist, Vec2d(0,0))

    # Create Body
    body = pm.Body(mass, inertia)          
    body.position = self.Vec2df(center)

    # Create Shapes
    a = b = None
    clr = self.get_color()

    shapes = []
    for p in pointlist:
      if a == None:
        # Starting Point
        a = p
        
      else:
        # Ending Point
        b = p

        # add shape beween a and b
        shape = pm.Segment(body, Vec2d(a), Vec2d(b), 2.0)
        shape.group = 1
        shape.friction = friction
        shape.color = clr
        shapes.append(shape)
        
        # Last Ending Point gets next starting Point
        a = b
          
        # Append to Space
        self.space.add(body, shapes)
    self.element_count += 1

  def set_gravity(self, gravity_vector):
    print "New Gravity:", gravity_vector
    self.gravity = gravity_vector
    self.space.gravity = gravity_vector
    
